#!/usr/bin/ruby
require 'pathname'
require 'tk'
require 'rubygems'
require 'open3'

begin
  require 'syntax/convertors/abstract'
rescue LoadError
  puts "The Syntax Gem was not found (http://syntax.rubyforge.org).\n"
  puts "Install it with 'gem install syntax' to enable syntax highlighting.\n"
end

if Module.constants.include? "Syntax"

module Syntax
  module Convertors
    class Tk < Abstract
      # Syntax converter for tk text elements. Takes a tk text element as
      # the first argument. It will clear all tags and then recompute the tags.
      # If no range is given, all text is highlighted. If line_start only is
      # given, then that single line is highlighted. If line_start and line_end
      # are given, then tht range is highlighted.
      def convert(text, line_start = nil, line_end = nil)
        if line_start
          pos = [line_start, 0]
          line_end ||= line_start
          tk_end = "#{line_end}.0 lineend"
        else
          pos = [1, 0]
          tk_end = 'end'
        end
        tk_start = pos.join '.'
        ret = []
#        regions = []
        text.tag_names.each do |tag|
          text.tag_remove tag, tk_start, tk_end
        end
        @tokenizer.tokenize text.get(tk_start, tk_end) do |tok|
          tk_start = pos.join '.'
#          case tok.instruction
#          when :region_close
#            ret << [tok.group, regions.pop, tk_pos]
#          when :region_open
#            regions.push tk_pos
#          else
          unless [:region_open, :region_close].include? tok.instruction
            tk_pos = pos.join '.'
            if tok["\n"]
              pos[0] += tok.count "\n"
              pos[1] = tok.size - tok.rindex("\n") - 1
            else
              pos[1] += tok.size
            end
            text.tag_add tok.group, tk_start, pos.join('.')
          end
        end
        ret
      end
    end
  end
end

end

# Core extension to the Tk module
module Tk
  # Helper method to convert a Tk index string "3.2" to line number and column
  # array [3,2]
  def idx_to_a(s)
    s.split('.').map{|i| i.to_i}
  end
end

# Core extensions to the Pathname class
class Pathname
  attr_accessor :project_level
end

class Editor
  TAG_COLORS = {
    :comment => 'grey',
    :keyword => 'blue',
    :method => 'red',
    :class => 'red',
    :symbol => 'blue',
    :string => 'purple',
    :number => 'purple'
  }

  def initialize(parent, window, syntax = nil)
    if syntax && Module.constants.include?("Syntax")
      @convertor = Syntax::Convertors::Tk.for_syntax syntax
    end
    @parent = parent
    view_initialize window
  end

  def set_text(text)
    @text.insert 'end', text
    @convertor.convert @text if @convertor
  end

  def draw_line_numbers
    first_line = Tk.idx_to_a(@text.index('@0,0'))[0]
    last_line = Tk.idx_to_a(@text.index('@65535,65535'))[0]
    @lines.state = :normal
    @lines.width = last_line.to_s.size
    @lines.delete 1.0, 'end'
    @lines.insert 1.0, (first_line..last_line).to_a.join("\n"), :right
    @lines.state = :disabled
  end

  def get_text
    @text.get(1.0, 'end').chop
  end

  # Handle a keyboard press event. don't do anything if there is no character,
  # or if a modifier other than the space key was pressed.
  def post_handle_key_press(e)
    return if e.char == "" || e.state > 1
    @convertor.convert @text, cursor_line
  end

  def post_handle_cut
    @convertor.convert @text, cursor_line
  end

  def post_handle_paste
    paste_lines = TkClipboard.get.count "\n"
    line_end = paste_lines > 0 ? cursor_line : nil
    @convertor.convert @text, cursor_line - paste_lines, line_end
  end

  def post_handle_undo
    @convertor.convert @text, 1, cursor_line
  end

  def handle_modified
    if @text.modified?
      @parent.tab_modify self
    else
      @parent.tab_unmodify self
    end
  end

  def clear_modified
    @text.modified = 'false'
  end

  def modified?
    @text.modified?
  end

  def cursor_line
    Tk.idx_to_a(@text.index('insert'))[0]
  end

  def view_show
    @frame.pack :fill => :both, :expand => true
  end

  def view_hide
    @frame.pack_forget
  end

  def view_destroy
    @frame.destroy
  end

  def view_initialize(window)
    # Create widgets
    @frame = TkFrame.new window, :pady => 3
    @text = TkText.new(
      @frame,
      :wrap => :none,
      :relief => :solid,
      :bd => 1,
      :highlightthickness => 0,
      :undo => true
    )
    @lines = TkText.new(
      @frame,
      :width => 3,
      :borderwidth => 0,
      :pady => 3,
      :highlightthickness => 0
    )
    TkTextNamedTag.new @lines, 'right', :justify => :right

    vbar = TkScrollbar.new @frame, :orient => :ver
    hbar = TkScrollbar.new @frame, :orient => :hor

    # Pack widgets
    vbar.pack :side => :right, :fill => :y
    hbar.pack :side => :bottom, :fill => :x
    @lines.pack :side => :left, :fill => :y
    @text.pack :fill => :both, :expand => true

    # Bind scroll bars to text box and to updating line numbers
    @text.yscrollcommand {|*args| vbar.set *args; draw_line_numbers}
    vbar.command {|*args| @text.yview *args; draw_line_numbers}
    @text.xscrollcommand {|*args| hbar.set *args}
    hbar.command {|*args| @text.xview *args}
    
    # Create a bind tag unique to this window, triggered after the class-name
    # bind tag. This allows us to handle events after the class-name bindings
    # have updated the text box.
    if @convertor
      TAG_COLORS.each do |group, fg_color|
        TkTextNamedTag.new @text, group, :foreground => fg_color
      end
      post_bindtag = TkBindTag.new_by_name "#{self}-post"
      @text.bindtags @text.bindtags.insert(2, post_bindtag)
      post_bindtag.bind('KeyPress') {|e| post_handle_key_press e}
      post_bindtag.bind('<Paste>') {|e| post_handle_paste}
      post_bindtag.bind('<Cut>') {|e| post_handle_cut}
      post_bindtag.bind('<Undo>') {|e| post_handle_undo}
      post_bindtag.bind('<Redo>') {|e| post_handle_undo}
    end

    @text.bind('<Modified>') {|e| handle_modified}
    # Tk by default interprets Control-o as a newline. This removes the binding.
    Tk.bind_remove TkText, 'Control-o'
  end
end

class Explorer
  def initialize(parent, window)
    @parent = parent
    @paths = []
    view_initialize window
  end

  def add_root_path(path)
    path.project_level = 0
    @paths << path
    view_insert @paths.size, path
    toggle_directory(@paths.size - 1)
  end

  private

  def toggle_directory(i)
    if path_is_open_dir? i
      while path_is_open_dir? i
        @paths.delete_at i + 1
        view_delete i + 1
      end
    else
      entries = fetch_dir_entries i
      @paths.insert i + 1, *entries
      view_insert i + 1, *entries
    end
  end

  def path_is_open_dir?(i)
    @paths[i + 1] && @paths[i + 1].project_level > @paths[i].project_level
  end

  def fetch_dir_entries(i)
    entries = @paths[i].children
    level = @paths[i].project_level + 1
    entries.each{|e| e.project_level = level}
    entries.reject!{|e| e.basename.to_s[/^\./]}

    entries.sort do |a, b|
      if b.file? == a.file?
        a <=> b
      else
        a.file? ? 1 : -1
      end
    end
  end

  def path_select(i)
    if @paths[i].file?
      @parent.open_file @paths[i]
    elsif @paths[i].directory?
      toggle_directory i
    end
  end

  def event_path_select
    path_select @list_box.curselection[0]
  end

  def event_context_menu(e)
    sel = @list_box.nearest(e.y)
    @list_box.selection_clear 0, 'end'
    @list_box.selection_set sel
    @file_menu.popup e.x_root, e.y_root
  end

  def event_svn_update
    sel = @paths[@list_box.curselection[0]]
    @parent.command_console "svn update #{sel}"
  end

  def view_initialize(window)
    @frame = TkFrame.new window do
      pack :side => :left, :fill => :both, :expand => true
    end

    @list_box = TkListbox.new @frame
    @list_box.pack :side => :left, :fill => :both, :expand => :true
    vbar = TkScrollbar.new @frame
    vbar.pack :side => :left, :fill => :y
    @list_box.yscrollcommand {|*args| vbar.set *args}
    vbar.command {|*args| @list_box.yview *args}

    @list_box.bind('Double-Button-1') {|e| event_path_select}
    @list_box.bind('Button-3') {|e| event_context_menu e}

    @file_menu = TkMenu.new @list_box
    @file_menu.tearoff = false
    @file_menu.add_command :label => 'open', :command => proc{event_path_select}
    @file_menu.add_command :label => 'SVN Update', :command => proc{event_svn_update}
  end

  def view_insert(i, *entries)
    entries.map! do |path|
      spaces = '  ' * path.project_level
      post = (path.directory? && !path.root?) ? '/' : ''
      [spaces, path.basename, post].join
    end
    @list_box.insert i, *entries
  end

  def view_delete(i)
    @list_box.delete i
  end
end

class Root
  SYNTAX_EXT_MAP = {
    :ruby => %w{rb rxml},
    :yaml => %w{yml yaml},
    :xml => %w{xml xhtml htm html rhtml}
  }

  def initialize
    view_initialize
    @ext_syntax_map = ext_syntax_hash
    @editors = {}
    @tabs = {}
  end

  def ext_syntax_hash
    h = {}
    SYNTAX_EXT_MAP.each do |syntax, extensions|
      extensions.each{|e| h[".#{e}"] = syntax.to_s}
    end
    h
  end

  def open_file(path)
    unless @editors[path]
      syntax = @ext_syntax_map[path.extname]
      @editors[path] = Editor.new self, @frame_right, syntax
      @editors[path].set_text path.read
      @editors[path].view_show
      @editors[path].clear_modified
      @tabs[path] = view_add_tab path
    end
    select_editor path
  end

  def command_console(command)
    unless @editors[command]
      @editors[command] = Editor.new self, @frame_right
      @editors[command].view_show
      @tabs[command] = view_add_tab command
      select_editor command

      Thread.new do
        Open3.popen3 command do |stdin, stdout, stderr|
          @editors[command].set_text stdout.read
          @editors[command].clear_modified
        end
      end
    end
  end

  def close_file
    return unless @current_file
    if @editors[@current_file].modified?
      case dialog_file_save
      when :yes then save_file
      when :cancel then return
      end
    end
    @editors.delete(@current_file).view_destroy
    view_del_tab @current_file
    @current_file = nil
    select_editor @editors.keys.last unless @editors.empty?
  end

  def select_editor(path)
    @editors[@current_file].view_hide if @current_file
    @editors[path].view_show

    view_unhighlight_tab @current_file if @current_file
    view_highlight_tab path

    @current_file = path
  end

  def handle_destroy
    @editors.each do |editor_path, editor|
      select_editor editor_path
      if editor.modified?
        case dialog_file_save
        when :yes then save_file
        when :cancel then return
        end
      end
    end
    Tk.exit
  end

  def handle_save
    save_file
    raise TkCallbackBreak
  end

  def save_file
    if @current_file
      File.open @current_file, 'w' do |f|
        f << @editors[@current_file].get_text
      end
    end
    @editors[@current_file].clear_modified
  end

  def file_dialog
    file = Tk.getOpenFile

    unless file == ''
      path = Pathname.new(file).realpath
      open_file path
    end
    raise TkCallbackBreak
  end

  # the blank instruction prevents the debugger from exiting event context
  def call_debugger(*args)
    debugger
    'blank instruction'
  end

  def toggle_display
    if @frame_left.winfo_manager == ""
      @pane.forget @frame_right
      @pane.add @frame_left, @frame_right
    else
      @pane.forget @frame_left
    end
  end

  def tab_modify(index)
    if index.class == Editor
      index = @editors.invert[index]
    end
    view_tab_modify index
  end

  def tab_unmodify(index)
    if index.class == Editor
      index = @editors.invert[index]
    end
    view_tab_unmodify index
  end

  def view_initialize
    @root = TkRoot.new {title 'Text Editor'}
    @root.geometry '800x600'

    @pane = TkPanedwindow.new nil, :orient => :horizontal do
      pack :fill => :both, :expand => true
    end
    @frame_left = TkFrame.new
    @frame_right = TkFrame.new
    @pane.add @frame_left, @frame_right

    @explorer = Explorer.new self, @frame_left
    @explorer.add_root_path Pathname.getwd

    @tab_frame = TkFrame.new @frame_right do
      pack :fill => :x
    end

    @root.bind('Control-s') {|e| handle_save}
    @root.bind('Control-d') {|e| call_debugger e}
    @root.bind('Control-o') {|e| file_dialog}
    @root.bind('Control-w') {|e| close_file}
    @root.protocol('WM_DELETE_WINDOW') {handle_destroy}
  end

  def dialog_file_save
    buttons = [:yes, :no, :cancel]
    res = TkDialog.new(
      :title => 'File Modified',
      :message => "#{@current_file} has been modified. Save it before closing?",
      :default => 0,
      :buttons => buttons
    ).value
    buttons[res]
  end

  def view_highlight_tab(index)
    @tabs[index].configure :bg => '#FFF'
  end

  def view_unhighlight_tab(index)
    @tabs[index].configure :bg => '#DDD'
  end

  def view_tab_modify(index)
    @tabs[index].text = "*#{@tabs[index].text}"
  end

  def view_tab_unmodify(index)
    @tabs[index].text = index.basename
  end

  def view_del_tab(index)
    @tabs.delete(index).destroy
  end

  def view_add_tab(path)
    tab = TkLabel.new(
      @tab_frame,
      :text => path.respond_to?(:basename) ? path.basename : path,
      :relief => :solid,
      :bd => 1,
      :padx => 4,
      :pady => 3,
      :highlightthickness => 2
    )
    tab.pack :side => :left
    tab.bind('Button') {|e| open_file path}
    tab.bind('Double-Button-1') {|e| toggle_display}
  end
end

t = Root.new
t.open_file Pathname.new(__FILE__).realpath
Tk.mainloop

